Skip to content

Add PYTHAINLP_OFFLINE support, is_offline_mode(), and PYTHAINLP_DATA env var to corpus path handling - #1309

Merged
bact merged 6 commits into
devfrom
copilot/remove-auto-download-corpus
Mar 7, 2026
Merged

Add PYTHAINLP_OFFLINE support, is_offline_mode(), and PYTHAINLP_DATA env var to corpus path handling#1309
bact merged 6 commits into
devfrom
copilot/remove-auto-download-corpus

Conversation

Copilot AI commented Mar 7, 2026

Copy link
Copy Markdown
Contributor

get_corpus_path() silently triggered network downloads whenever a corpus file was missing, making behavior unpredictable in web APIs and other non-interactive contexts. There was also no way for operators to disable automatic downloading at deployment time, no public API to check offline status, and the data directory env var did not follow established conventions.

What was wrong

get_corpus_path() called download() unconditionally whenever a corpus file was missing — whether or not the caller wanted a download, and with no mechanism to prevent it in production deployments. There was no public is_offline_mode() helper, and the data directory was configured via PYTHAINLP_DATA_DIR (inconsistent with the NLTK_DATA convention used by similar libraries). Corpus-not-found error messages were inconsistent across callers, used different exception types, and gave no CLI download hint.

How this fixes it

PYTHAINLP_OFFLINE environment variable (same semantics as HF_HUB_OFFLINE):

  • If PYTHAINLP_OFFLINE is set to a truthy value (e.g. "1"), get_corpus_path() raises FileNotFoundError immediately when the corpus is not cached locally — no network call is made.
  • If PYTHAINLP_OFFLINE is unset or falsy, get_corpus_path() auto-downloads as before (backward-compatible default).
  • download() — whether called from Python code or the thainlp data get CLI — always executes regardless of PYTHAINLP_OFFLINE. An explicit call to download() is a deliberate user action and must not be blocked. PYTHAINLP_OFFLINE only prevents the automatic download triggered internally by get_corpus_path().
# Offline mode: raises immediately with an actionable message
# PYTHAINLP_OFFLINE=1
path = get_corpus_path("wiki_lm_lstm")
# raises FileNotFoundError:
#   corpus-not-found name='wiki_lm_lstm'
#     Corpus 'wiki_lm_lstm' not found locally.
#     PYTHAINLP_OFFLINE is set; automatic downloading is disabled.
#     To download, unset PYTHAINLP_OFFLINE, then run:
#       Python: pythainlp.corpus.download('wiki_lm_lstm')
#       CLI:    thainlp data get wiki_lm_lstm

# Explicit download always works, even with PYTHAINLP_OFFLINE=1
from pythainlp.corpus import download
download("wiki_lm_lstm")  # proceeds normally

pythainlp.is_offline_mode() public API (mirrors huggingface_hub.is_offline_mode()):

  • Added in pythainlp/tools/path.py, exported from pythainlp.tools and from the top-level pythainlp package.
  • Allows developers to programmatically check whether offline mode is active.
  • Documents clearly that the flag only affects automatic downloads, not explicit download() calls.
import pythainlp
print(pythainlp.is_offline_mode())  # True if PYTHAINLP_OFFLINE=1

PYTHAINLP_DATA environment variable (follows NLTK_DATA pattern):

  • PYTHAINLP_DATA is now the preferred env var for overriding the data directory.
  • PYTHAINLP_DATA_DIR is still accepted for backward compatibility but emits a DeprecationWarning.
  • If both are set simultaneously, a ValueError is raised to prevent silent misconfiguration.
  • All documentation, CLI help text, and examples updated to use PYTHAINLP_DATA.

Consistent, machine-parsable error messages across all callers:

  • Every corpus-not-found FileNotFoundError now follows a uniform format with both Python API and CLI download hints:
    corpus-not-found name='<name>'
      Corpus '<name>' not found.
        Python: pythainlp.corpus.download('<name>')
        CLI:    thainlp data get <name>
    
  • All callers updated: corpus/core.py, transliterate/ (thai2rom, thaig2p, thai2rom_onnx, w2p), tag/ (thainer, thai_nner, unigram, perceptron), spell/symspellpy, word_vector/core, augment/word2vec/ (ltw2v, thai2fit), generate/thai2fit, ulmfit/core.
  • Remaining RuntimeError and ValueError corpus-not-found errors converted to FileNotFoundError for consistency.

Refactoring to reduce cognitive complexity:

  • Extracted is_offline_mode() (public) from the former private _is_offline() helper.
  • Extracted _resolve_corpus_file_path() helper to flatten the nested folder/file branching inside get_corpus_path(). Cognitive complexity reduced to ~12 (below the target of 15).

Caller updates (safety nets for when download fails or path is unresolvable):

  • Callers that used the path directly without checking (thai2rom, thaig2p, thai2rom_onnx, word_vector/core, spell/symspellpy) now raise FileNotFoundError with a download instruction when the path is falsy.
  • transliterate/w2p.py had its own inline auto-download pattern; replaced with FileNotFoundError.
  • All is None guards updated to not path: thainer, unigram, perceptron, thai_nner, en_th, ulmfit/core, ltw2v, thai2fit.

Test updates:

  • test_get_corpus_path_offline_mode covers all three behaviors: PYTHAINLP_OFFLINE=1 raises for missing corpus, PYTHAINLP_OFFLINE=1 raises for registered-but-missing file, and file present returns path normally.
  • test_download_ignores_offline_mode verifies download() succeeds with PYTHAINLP_OFFLINE=1.
  • test_custom_data_dir_new verifies PYTHAINLP_DATA is respected.
  • test_custom_data_dir verifies PYTHAINLP_DATA_DIR still works but emits DeprecationWarning.
  • test_custom_data_dir_conflict verifies both set simultaneously raises ValueError.
  • test_is_offline_mode verifies all truthy/falsy values of PYTHAINLP_OFFLINE.
  • test_zip now explicitly downloads before asserting path existence.
  • TagTestCase, PerceptronTaggerTestCase, and GenerateTestCase each gain a setUpClass that downloads their required corpora.

Your checklist for this pull request

  • Passed code styles and structures
  • Passed code linting checks and unit test
Original prompt

This section details on the original issue you should resolve

<issue_title>pythainlp.corpus.get_corpus_path() should not try to download the corpus automatically</issue_title>
<issue_description>เสนอว่าไม่ควรใช้ pythainlp.corpus.get_corpus_path() นั้นเรียกดาวน์โหลดแฟ้มโดยอัตโนมัติหากมันหาแฟ้มไม่เจอครับ ควรจะปล่อยให้ผู้ใช้ตัดสินใจเองมากกว่า

Current get_corpus_path() try to download the corpus file if it is not yet exist locally:

def get_corpus_path(name: str) -> Union[str, None]:

    if db.search(query.name == name):
        path = get_full_data_path(db.search(query.name == name)[0]["file"])

        if not os.path.exists(path):
            download(name)

I proposed that it shouldn't do that.

If the file is not exist, user/developer should get notified and decided if they want to download it or not (using API or using command line).

Currently, inside pythainlp module, every single call of get_corpus_path() do exactly that. They check if returned path is "true", if not they call pythainlp.corpus.download() by themselves:

So removing the auto-download inside pythainlp.corpus.get_corpus_path() will not change the behavior of those functions in submodules. (Anyway, it can be further discuss if we want to remove the auto-downloads from those submodules as well or not).

Proposed return values

I propose these for discussion:

  • full path - if the corpus name is valid and the file is exist locally
  • "" (empty string) - if the corpus name is valid but the file is not exist locally
  • None - if the corpus name is not valid (not inside the corpus database)

</issue_description>

<agent_instructions>If required corpus files are not existed at expected locations, including ones in config file or in the prescribed environment variables, raise appropriate error with a message to user on why this failed, how to download the corpus, where is the expected location. Keep the messages concise, clear, and consistent across functions.</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@wannaphong เห็นด้วยครับ เราควรถามผู้ใช้งานก่อนว่ายินยอมไหม ความเห็นผม ผมคิดว่า ถ้าไม่พบ ให้ขึ้น Error ไปเลยว่าไม่พบ พร้อมกับบอกวิธีติดตั้ง corpus นั้น แต่ไม่ควรขึ้นถามว่าคุณไม่มี corpus นี้ ต้องการติดตั้งไหม? เพราะเพื่อป้องกันปัญหาเวลาคนนำไปใช้งานกับ Web api ที่อาจจะมองไม่เห็นครับ @bact อาจจะมี error message ให้มนุษย์รู้ และ raise FileNotFoundError เพื่อให้โปรแกรมจัดการต่อได้? @p16i seems the issue has been solved? @wannaphong ทำให้ทุกฟังก์ชันที่ต้องโหลด model หรือ dataset ให้มีฟังก์ชัน setup ไหมครับ หลักการ คือ ในการใช้งานครั้งแรก ให้ผู้ใช้งานเรียกฟังก์ชันนี้ก่อน โดยจะโชว์ license กับรายละเอียด ถ้าผู้ใช้งานยินยอมให้พิมพ์ y แต่จะโหลด model หรือ dataset

นอกจากนั้น ฟังก์ชัน setup สามารถยินยอมตามพารามิเตอร์ได้โดยตรง เผื่อผู้ใช้งานที่ไม่สะดวกมากดในคอมมาไลน์ เช่น setup(agree=True) โดยในโค้ดของเรา agree ค่าเริ่มต้นจะเป็น False

หากผู้ใช้งานไม่เคยเรียก พอเรียกใช้งานคำสั่งนั้นจะขึ้น Error แล้วอธิบายว่าให้เรียกฟังก์ชัน setup ก่อน</comment_new>
<comment_new>@bact
ไอเดียดี โดยให้มีไฟล์ config กำหนดไว้ และบันทึกไว้ว่าผู้ใช้เคยยอมรับเงื่อนไข model/dataset ไหนแล้ว

@p16i จริงๆ จำเป็นไมว่าต้องมีไฟล์ กำลังคิดอยู่ว่าถ้า user ยอมรับแล้ว ก็จะมี directory ข้อมูลอยู่ เราก้อเช็คจากตรงนั้นก้อได้ไหมน่ะ?

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI assigned Copilot and bact Mar 7, 2026
@bact bact added the corpus corpus/dataset-related issues label Mar 7, 2026
Copilot AI and others added 2 commits March 7, 2026 07:34
…g in callers

Co-authored-by: bact <128572+bact@users.noreply.github.com>
…t method, update docstring

Co-authored-by: bact <128572+bact@users.noreply.github.com>
Copilot AI changed the title [WIP] Remove automatic download in get_corpus_path() Remove auto-download from get_corpus_path(); raise errors in callers Mar 7, 2026
Copilot finished work on behalf of bact March 7, 2026 07:38
@bact

bact commented Mar 7, 2026

Copy link
Copy Markdown
Member

@copilot

  1. Use this logic when deciding about corpus file access

    • If the file is available at get_pythainlp_data_path(), use it
    • If the file is not available, check if PYTHAINLP_OFFLINE is set and it is 1 (or Truthy), do not download the file
    • If the file is not available, check if PYTHAINLP_OFFLINE is not set or it is 0 (or Falsy), download the file

    (Same behavior as the HF_HUB_OFFLINE : https://huggingface.co/docs/huggingface_hub/main/en/package_reference/environment_variables#hfhuboffline)

  2. Refactor get_corpus_path() and get_pythainlp_data_path() to reduce its cognitive complexity. Aim at below 15. See: https://sonarcloud.io/project/issues?sinceLeakPeriod=true&issueStatuses=OPEN%2CCONFIRMED&pullRequest=1309&id=PyThaiNLP_pythainlp&open=AZzHOLOggorC8QBZd527

  3. Update the test

Co-authored-by: bact <128572+bact@users.noreply.github.com>

Copilot AI commented Mar 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot

  1. Use this logic when deciding about corpus file access
    ...

Done in commit 0de2e35:

  1. PYTHAINLP_OFFLINE support — Added _is_offline() helper (same truthy-value semantics as HF_HUB_OFFLINE: "", "0", "false", "no", "off" are falsy). get_corpus_path() now follows exactly the three-step logic you described:

    • File available locally → use it
    • File not available + PYTHAINLP_OFFLINE=1 → raise FileNotFoundError with a message explaining the env var and how to download
    • File not available + PYTHAINLP_OFFLINE unset/falsy → auto-download (restores backward-compatible behavior)
      download() also refuses with a clear message when PYTHAINLP_OFFLINE is set.
  2. Cognitive complexity — Extracted two helpers (_is_offline() and _resolve_corpus_file_path()) to flatten the nested branching in get_corpus_path(). Measured complexity is ~12, below the 15 target.

  3. Tests updatedtest_get_corpus_path_offline_mode covers all three cases using patch.dict(os.environ, {"PYTHAINLP_OFFLINE": "1"/""}).

Copilot AI changed the title Remove auto-download from get_corpus_path(); raise errors in callers Add PYTHAINLP_OFFLINE support to get_corpus_path(); raise errors in callers Mar 7, 2026
Copilot finished work on behalf of bact March 7, 2026 08:27
@bact

bact commented Mar 7, 2026

Copy link
Copy Markdown
Member

@copilot

  1. Fix any remaining issues from the unit tests
  2. Add pythainlp.is_offline_mode() helper function for developers to programmatically check if the library is in offline mode or not (same as Hugging Face Hub library https://huggingface.co/docs/huggingface_hub/main/en/package_reference/utilities#huggingface_hub.is_offline_mode )
  3. Replace PYTHAINLP_DATA_DIR environment variable with PYTHAINLP_DATA (follows pattern of NLTK_DATA):
    • Keep PYTHAINLP_DATA_DIR as an alias of PYTHAINLP_DATA.
    • Use PYTHAINLP_DATA in documentation, comments.
    • If either PYTHAINLP_DATA or PYTHAINLP_DATA_DIR exist, use it.
    • If PYTHAINLP_DATA_DIR is being used, display warning that this env var is deprecated and recommend transition to PYTHAINLP_DATA
    • If both of them exist, terminate and display message that they should not be exist together and recommend to use PYTHAINLP_DATA only
  4. Run mypy, ruff, flake8, static type checkers and fix any issues
  5. Update documentation and comments

… deprecation

Co-authored-by: bact <128572+bact@users.noreply.github.com>

Copilot AI commented Mar 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot

  1. Fix any remaining issues from the unit tests
    ...

Done in commit 4539f31:

  1. Unit tests — all 123 core tests pass; no remaining issues.

  2. pythainlp.is_offline_mode() — added in pythainlp/tools/path.py, exported from pythainlp.tools and from the top-level pythainlp package. Follows the same truthy/falsy semantics as huggingface_hub.is_offline_mode().

  3. PYTHAINLP_DATAget_pythainlp_data_path() now resolves the data directory in this order:

    • PYTHAINLP_DATA (preferred, follows NLTK_DATA pattern)
    • PYTHAINLP_DATA_DIR (still accepted, emits DeprecationWarning)
    • Both set simultaneously → raises ValueError
      CLI help text, README, README_TH.md, and docs/notes/installation.rst all updated.
  4. Linting — ruff and mypy both pass on all modified files; CodeQL found no alerts.

Copilot AI changed the title Add PYTHAINLP_OFFLINE support to get_corpus_path(); raise errors in callers Add PYTHAINLP_OFFLINE support, is_offline_mode(), and PYTHAINLP_DATA env var to corpus path handling Mar 7, 2026
Copilot finished work on behalf of bact March 7, 2026 09:24
@bact
bact marked this pull request as ready for review March 7, 2026 09:29
@bact
bact requested a review from wannaphong March 7, 2026 09:30
@bact bact added this to the 5.3 milestone Mar 7, 2026

@wannaphong wannaphong left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use

$ thainlp data get

to download cropus.

]
raise FileNotFoundError(
f"Corpus file(s) not found: {', '.join(missing)}. "
f"To download, run: pythainlp.corpus.download('<name>') "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

# get the model, download it if it's not available locally
self.__model_filename: str = get_corpus_path(_MODEL_NAME) # type: ignore[assignment]
if not self.__model_filename:
raise FileNotFoundError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

Comment thread pythainlp/transliterate/w2p.py Outdated
if not self.checkpoint:
raise FileNotFoundError(
f"Corpus '{_MODEL_NAME}' not found. "
f"To download: pythainlp.corpus.download('{_MODEL_NAME}', version='0.2')"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

# get the model, download it if it's not available locally
self.__model_filename = get_corpus_path(_MODEL_NAME) # type: ignore[assignment]
if not self.__model_filename:
raise FileNotFoundError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

Comment thread pythainlp/tag/thai_nner.py Outdated
if not path_model:
raise FileNotFoundError(
"Corpus 'thai_nner' not found. "
"To download: pythainlp.corpus.download('thai_nner')"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

# Load bigram dictionary from downloaded corpus
bigram_path = get_corpus_path(_BIGRAM_CORPUS_NAME)
if not bigram_path:
raise FileNotFoundError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

Comment thread pythainlp/corpus/core.py Outdated
raise FileNotFoundError(
f"Corpus '{name}' not found locally. "
f"PYTHAINLP_OFFLINE is set; automatic downloading is disabled. "
f"To download, unset PYTHAINLP_OFFLINE and run: "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

Comment thread pythainlp/corpus/core.py Outdated
f"Corpus '{name}' expected at '{path}' but file not found. "
f"PYTHAINLP_OFFLINE is set; automatic re-downloading is disabled. "
f"To re-download, unset PYTHAINLP_OFFLINE and run: "
f"pythainlp.corpus.download('{name}', force=True)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add

$ thainlp data get

too.

@bact

bact commented Mar 7, 2026

Copy link
Copy Markdown
Member

@copilot

  1. Add thainlp data get command line as well when suggesting how to download corpus (along with the programmatic Python code)
  2. Review all corpus/file not found error messages and revise/reformat them to make them consistent in wording and format. Make it concise and to the point, and machine parsable.
  3. Review README and other documentation to reflect the new environment variable. Add documentation on PYTHAINLP_OFFLINE usage.

…o all error messages

Co-authored-by: bact <128572+bact@users.noreply.github.com>
@sonarqubecloud

sonarqubecloud Bot commented Mar 7, 2026

Copy link
Copy Markdown

Copilot finished work on behalf of bact March 7, 2026 12:42
@bact
bact requested a review from wannaphong March 7, 2026 13:18
@bact

bact commented Mar 7, 2026

Copy link
Copy Markdown
Member

@wannaphong added command line suggestions

@bact
bact merged commit 571a823 into dev Mar 7, 2026
27 checks passed
@bact
bact deleted the copilot/remove-auto-download-corpus branch March 7, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

corpus corpus/dataset-related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pythainlp.corpus.get_corpus_path() should not try to download the corpus automatically

3 participants